Skip to content

BUGFIX: template-no-unsupported-role-attributes — honor aria-query attribute constraints#2726

Merged
NullVoxPopuli merged 11 commits intoember-cli:masterfrom
johanrd:fix/implicit-role-attribute-constraints
Apr 25, 2026
Merged

BUGFIX: template-no-unsupported-role-attributes — honor aria-query attribute constraints#2726
NullVoxPopuli merged 11 commits intoember-cli:masterfrom
johanrd:fix/implicit-role-attribute-constraints

Conversation

@johanrd
Copy link
Copy Markdown
Contributor

@johanrd johanrd commented Apr 21, 2026

Note

This is part of a series where Claude has audited eslint-plugin-ember against jsx-a11y, vuejs-accessibility, angular-eslint, lit-a11y and html-validate, ember-template-lint, and the HTML and WCAG specs.

  • Premise 1: aria-query's elementRoles map encodes element → implicit-role pairings that depend on attribute state. Each entry lists attributes that must be set, undefined, or match a specific value.
  • Premise 2: Our getImplicitRole helper returned the first entry whose tag name matched, ignoring the attribute constraints on subsequent entries.
  • Conclusion: Several common elements got the wrong implicit role.

Concrete consequences of the old logic:

HTML Implicit role we returned Correct implicit role
<input type="text"> (no list) combobox textbox
`<input type="email tel url">(nolist`)
<input type="password"> button none (unmapped by aria-query, matches HTML-AAM)

The first two block valid usages involving attributes that textbox supports but combobox does not — notably aria-placeholder (covered by the new tests) and aria-multiline. Both textbox and combobox list aria-required in aria-query 5.3.2, so that attribute is not the concern here. The third row is arbitrarily wrong — there's no reason a password input should ever be treated as a button. <input type="password"> being left unmapped is consistent with HTML-AAM, which assigns it "No corresponding role."

Fix: walk every elementRoles entry whose name matches the tag; evaluate each attribute spec (checking constraints: ["set"], constraints: ["undefined"], and required values); pick the entry with the most attribute constraints satisfied.

Eight new valid tests cover the textbox/password paths. One existing test updated: <input type="email" aria-level={{this.level}} /> now maps to textbox in the error message; a sibling case with list="x" covers the combobox path.

Prior art

Plugin Rule Verified behavior
jsx-a11y role-supports-aria-props Computes implicit role via per-element lookup tables under src/util/implicitRoles/ (e.g. a.js checks for href before returning "link"); then validates that every aria-* attribute is in the role's supported-props list from aria-query.
vuejs-accessibility no equivalent rule vue-a11y's aria-props rule only validates that attribute names beginning with aria- are known ARIA attributes (via aria.has(lowered)); it does not compute implicit roles or check role↔prop compatibility.
@angular-eslint/template no equivalent rule angular-eslint's valid-aria validates ARIA attribute names and literal values against aria-query's definitions; it does not resolve implicit roles from element + attributes, so the role-supports-props check does not apply. role-has-required-aria is the inverse check (role → required props), not role → supported props.
lit-a11y role-supports-aria-attr Only reacts to an explicit role= attribute (Object.keys(element.attribs).includes('role')); does not compute an implicit role from tag + attributes, so the implicit-role false positive does not arise.

johanrd added 11 commits April 25, 2026 17:38
…ute constraints in implicit-role lookup

The old getImplicitRole picked the first elementRoles entry whose name
matched the tag. For <input> it tried a type-attribute match but
ignored the rest of aria-query's constraint annotations.

Concrete consequences of the old logic:

- <input type="text"> without a `list` attribute returned "combobox"
  (because aria-query's {type=text, list=set}→combobox entry appears
  before {type=text, list=undefined}→textbox). Correct implicit role is
  textbox.
- <input type="email|tel|url"> behaved the same way.
- <input type="password"> isn't mapped by aria-query; the fallback
  branch returned the first input entry — "button".

The new implementation walks every elementRoles key whose tag matches,
checks each attribute spec (honoring `constraints: ["set"]` and
`constraints: ["undefined"]` as well as required values), and picks the
entry with the most attribute constraints satisfied.

Behavioral impact:

- Text-like inputs without a list attribute now correctly resolve to
  textbox and accept aria-required / aria-readonly / aria-placeholder.
- <input type="password"> now resolves to no implicit role, so global
  ARIA attrs don't trip the rule (matching jsx-a11y's treatment).
- One existing test updated: <input type="email"> now maps to textbox
  in the error message; added a sibling case with `list="x"` to cover
  the combobox path.
- Eight new valid tests cover the textbox/password paths.
…peer cases

Translates 33 cases from peer-plugin rules:
  - jsx-a11y role-supports-aria-props
  - lit-a11y role-supports-aria-attr
  - vuejs-accessibility (no direct equivalent; divergence noted)

The fixture documents where our rule now matches jsx-a11y (notably
<input type="email"> without `list` mapping to "textbox" per the
aria-query attribute constraints) plus the sibling `list=…` case
mapping to "combobox".
…euristic

The "pick entry with most attribute constraints satisfied" resolution is a
heuristic, not a spec-documented approach. aria-query's elementRoles is an
unordered Map; aria-query does not publish a resolution order for
multi-match cases. Name the inference, cite the motivating bug cases, and
note that if aria-query ever publishes resolution semantics we should
switch to those.
- getStaticAttrValue() now trims chars so callers don't handle whitespace.
- Lowercase the node value when comparing against aria-query's attrSpec.value
  (HTML enumerated attribute values are ASCII case-insensitive per HTML
  common-microsyntaxes §2.3.3).
- Remove unused createUnsupportedAttributeErrorMessage() dead code; the rule
  uses messageId/data exclusively.
… by tag (Copilot review)

Benchmarked ~2.6× speedup on getImplicitRole — bucketing the static
elementRoles Map by tag turns the per-call scan of ~80 keys into a 1-5
key lookup per tag. Parity verified across 15 representative tag/attr
combinations before landing. Matches the Q29 optimization pattern on
#51's isSemanticRoleElement.
…ases, drop audit fixture

Upstream maintainers don't want the per-PR `tests/audit/peer-parity`
pattern. Port two unique audit cases into the regular suite:
- `<body aria-expanded=...>` — pins our implicit-role resolution
  ("generic" via aria-query, vs jsx-a11y's "document"); both flag,
  message differs.
- `<a aria-checked />` — acknowledged false-positive vs jsx-a11y; we
  resolve href-less <a> to implicit role "generic" where aria-checked
  is unsupported, jsx-a11y treats it as having no role and accepts.

All other audit cases were already covered by the regular tests.
…n <a> divergence note

Researched the href-less <a> divergence: per HTML-AAM 1.2 §3.5.3 the
implicit role IS `generic` (which is what we resolve via aria-query).
jsx-a11y's `getImplicitRoleForAnchor` returns `''` for href-less <a>,
and its role-supports-aria-props rule then early-returns on no-role and
silently allows any aria-* — jsx-a11y's own source comments this as
"This actually isn't true - should fix in future release". vue-a11y
walks aria-query the same way we do and reaches the same conclusion.
We're spec-current; jsx-a11y is spec-stale. Updated the test comment
to reflect this rather than calling our behavior an "acknowledged
false-positive".
@johanrd johanrd force-pushed the fix/implicit-role-attribute-constraints branch from 248b3ec to dc93f21 Compare April 25, 2026 15:38
@NullVoxPopuli NullVoxPopuli merged commit 0da6be9 into ember-cli:master Apr 25, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants